Perceptually Uniform Color Interpolation

This was a small for fun experiment done on a lazy Saturday. Check the original at https://github.com/dreavjr/colorinterp.

(c̸) 2017 Eduardo Valle. This software is in Public Domain; it is provided "as is" without any warranties. Please check https://github.com/dreavjr/colorinterp/blob/master/LICENSE.md

Imports and initializations


In [19]:
import re

import colorlover as cl
from IPython.display import HTML
import numpy as np

num_original = 4
num_interpolated = 19
colors = cl.scales[str(num_original).strip()]['qual']['Set1']
HTML(cl.to_html( colors ))


Out[19]:

Parsing


In [20]:
colors_list = [ re.search('rgb\(([0-9]+),([0-9]+),([0-9]+)\)', c).groups() for c in colors ]
colors_array = np.asarray([ [ float(p) for p in c ] for c in colors_list ])
# DEBUG : forces sRGB correspondence on my hardware
# colors = np.asarray([ [172, 205, 229], [59, 117, 184], [181, 225, 128] ], dtype='float') 
colors_array


Out[20]:
array([[ 228.,   26.,   28.],
       [  55.,  126.,  184.],
       [  77.,  175.,   74.],
       [ 152.,   78.,  163.]])

Conversion from RGB (assumed sRGB) to XYZ

The formulas copied from Wikipedia article on sRGB.


In [21]:
Scale = 255.0
srgb = colors_array/Scale
srgb_thres=0.04045
srgb_linear = np.empty(srgb.shape)
srgb_linear[srgb<=srgb_thres] = (srgb/12.92)[srgb<=srgb_thres]
srgb_linear[srgb>srgb_thres] = np.power(((srgb+0.055)/1.055),2.4)[srgb>srgb_thres]
srgb2xyz = np.asarray([ [ 0.4124, 0.3576, 0.1805 ],
                        [ 0.2126, 0.7152, 0.0722 ],
                        [ 0.0193, 0.1192, 0.9505 ] ])
# xyz = srgb2xyz.dot(srgb_linear[i])
xyz = srgb_linear.dot(srgb2xyz.T)
xyz


Out[21]:
array([[ 0.32573904,  0.1731661 ,  0.02724212],
       [ 0.17688132,  0.19194626,  0.48120069],
       [ 0.19626571,  0.32732137,  0.11762073],
       [ 0.22284143,  0.14768522,  0.36326437]])

Conversion from XYZ to L*a*b*

The formulas copied from Wikipedia article on L*a*b* and Bruce Lind Bloom's page on color conversion.


In [22]:
Lab_thresh = 216.0/24389.0
Lab_kappa  = 24389.0/27.0
def xyz2lab(t):
    t2 = np.empty(t.shape)
    cond = t<=Lab_thresh
    t2[cond] = (Lab_kappa*t+16.0)[cond]
    t2[np.logical_not(cond)] = np.power(t,(1.0/3.0))[np.logical_not(cond)]
    return t2

WhiteD65 = np.asarray([ 0.95047, 1.00000, 1.08883 ])
xyz_ratio = xyz/WhiteD65

pre_lab = xyz2lab(xyz_ratio)
ell = 116.0*pre_lab[:,1] - 16.0
a   = 500.0*(pre_lab[:,0]-pre_lab[:,1])
b   = 200.0*(pre_lab[:,1]-pre_lab[:,2])
lab = np.asarray([ell, a, b]).T
lab


Out[22]:
array([[ 48.65651299,  71.21070676,  52.98109021],
       [ 50.91413545,  -2.95910047, -36.97247291],
       [ 63.94342979, -49.05118575,  42.58212343],
       [ 45.31550883,  44.02012702, -32.9967354 ]])

Interpolate colors in the perceptually uniform L*a*b* space


In [23]:
originals = np.arange(ell.shape[0])
interpolated = np.linspace(originals[0], originals[-1], num=num_interpolated)
ell_interp = np.interp(interpolated, originals, ell) 
a_interp = np.interp(interpolated, originals, a) 
b_interp = np.interp(interpolated, originals, b) 

# DEBUG: disables interpolation
# ell_interp = ell
# a_interp = a
# b_interp = b

# Demonstration: also interpolates in the rgb space ---
rgb = colors_array.T
red_interp   = np.interp(interpolated, originals, rgb[0]) 
green_interp = np.interp(interpolated, originals, rgb[1])
blue_interp  = np.interp(interpolated, originals, rgb[2])
rgb_interp = np.asarray([red_interp, green_interp, blue_interp]).T
# --- this is not needed for the perceptual interpolation, only for comparison

np.asarray([ell_interp, a_interp, b_interp]).T


Out[23]:
array([[ 48.65651299,  71.21070676,  52.98109021],
       [ 49.0327834 ,  58.84907222,  37.98882969],
       [ 49.40905381,  46.48743768,  22.99656917],
       [ 49.78532422,  34.12580314,   8.00430865],
       [ 50.16159463,  21.76416861,  -6.98795187],
       [ 50.53786504,   9.40253407, -21.98021239],
       [ 50.91413545,  -2.95910047, -36.97247291],
       [ 53.08568451, -10.64111468, -23.71337352],
       [ 55.25723356, -18.3231289 , -10.45427413],
       [ 57.42878262, -26.00514311,   2.80482526],
       [ 59.60033168, -33.68715732,  16.06392465],
       [ 61.77188073, -41.36917154,  29.32302404],
       [ 63.94342979, -49.05118575,  42.58212343],
       [ 60.83877629, -33.53930029,  29.98564696],
       [ 57.7341228 , -18.02741483,  17.38917049],
       [ 54.62946931,  -2.51552937,   4.79269401],
       [ 51.52481582,  12.99635609,  -7.80378246],
       [ 48.42016232,  28.50824156, -20.40025893],
       [ 45.31550883,  44.02012702, -32.9967354 ]])

Conversion from L*a*b* to XYZ

The formulas copied from Wikipedia article on L*a*b*. and Bruce Lind Bloom's page on color conversion.


In [24]:
def lab2xyz(t):
    t2 = np.empty(t.shape)
    cond =t<=Lab_thresh
    t2[cond] = ((t-16.0)/Lab_kappa)[cond]
    t2[np.logical_not(cond)]  = np.power(t,3.0)[np.logical_not(cond)]
    return t2

ell_interp_scaled = (ell_interp+16.0)/116.0
a_interp_scaled = a_interp/500.0
b_interp_scaled = b_interp/200.0
pre_x_interp = lab2xyz(ell_interp_scaled+a_interp_scaled)
pre_y_interp = lab2xyz(ell_interp_scaled)
pre_z_interp = lab2xyz(ell_interp_scaled-b_interp_scaled)
pre_xyz_interp = np.asarray([pre_x_interp, pre_y_interp, pre_z_interp]).T
xyz_interp = pre_xyz_interp*WhiteD65
xyz_interp #, xyz


Out[24]:
array([[ 0.32573904,  0.1731661 ,  0.02724212],
       [ 0.29665592,  0.17620696,  0.05545863],
       [ 0.26935755,  0.17928322,  0.0984861 ],
       [ 0.24378742,  0.18239507,  0.1594493 ],
       [ 0.21988902,  0.18554272,  0.24147296],
       [ 0.19760582,  0.18872638,  0.34768185],
       [ 0.17688132,  0.19194626,  0.48120069],
       [ 0.18001911,  0.21124684,  0.39654967],
       [ 0.18319378,  0.23179972,  0.32245837],
       [ 0.18640556,  0.25364425,  0.25822331],
       [ 0.18965467,  0.27681979,  0.20314101],
       [ 0.19294131,  0.30136571,  0.15650797],
       [ 0.19626571,  0.32732137,  0.11762073],
       [ 0.20053953,  0.29064801,  0.14654741],
       [ 0.20487495,  0.25682162,  0.17986583],
       [ 0.20927241,  0.22572718,  0.21788636],
       [ 0.21373235,  0.19724966,  0.26091937],
       [ 0.21825521,  0.17127401,  0.30927525],
       [ 0.22284143,  0.14768522,  0.36326437]])

Conversion from XYZ to RGB (assumed sRGB)

The formulas copied from Wikipedia article on sRGB.


In [25]:
xyz2srgb = np.asarray([ [  3.2406, -1.5372, -0.4986 ],
                        [ -0.9689,  1.8758,  0.0415 ],
                        [  0.0557, -0.2040,  1.0570 ] ])
# srgb_linear_interp = xyz2srgb.dot(xyz_interp[i])
srgb_linear_interp = xyz_interp.dot(xyz2srgb.T)
srgb_interp = np.empty(srgb_linear_interp.shape)

srgb_interp[srgb_linear_interp<=0.0031308] = (12.92*srgb_linear_interp)[srgb_linear_interp<=0.0031308]
srgb_interp[srgb_linear_interp>0.0031308] = ((1.055)*np.power(srgb_linear_interp,(1.0/2.4)) - 0.055)[srgb_linear_interp>0.0031308]
srgb_interp[srgb_interp<0.0] = 0.0
srgb_interp[srgb_interp>1.0] = 1.0
srgb_device_interp = np.round(srgb_interp*Scale)
# print(srgb_linear_interp, srgb_interp, srgb_device_interp, colors, sep='\n\n')
srgb_device_interp


Out[25]:
array([[ 228.,   26.,   28.],
       [ 213.,   60.,   56.],
       [ 195.,   80.,   81.],
       [ 175.,   94.,  106.],
       [ 150.,  106.,  132.],
       [ 117.,  117.,  158.],
       [  55.,  126.,  184.],
       [  70.,  134.,  167.],
       [  78.,  142.,  150.],
       [  82.,  150.,  132.],
       [  84.,  158.,  114.],
       [  82.,  167.,   95.],
       [  77.,  175.,   74.],
       [ 101.,  161.,   92.],
       [ 117.,  147.,  108.],
       [ 130.,  132.,  122.],
       [ 139.,  116.,  136.],
       [ 147.,   98.,  150.],
       [ 152.,   78.,  163.]])

Formatting the output


In [26]:
# This --- finally --- is the desired output
colors_interpolated = [ 'rgb(%d,%d,%d)' % tuple(c) for c in srgb_device_interp ]
after_hsl_and_back = cl.to_rgb(cl.to_hsl(colors_interpolated))
rgb_interpolated = [ 'rgb(%d,%d,%d)' % tuple(c) for c in rgb_interp ]

print(colors)
HTML(cl.to_html( colors ))


['rgb(228,26,28)', 'rgb(55,126,184)', 'rgb(77,175,74)', 'rgb(152,78,163)']
Out[26]:

In [27]:
print(colors_interpolated)
print(after_hsl_and_back)
print(rgb_interpolated)
HTML(cl.to_html(after_hsl_and_back))
HTML('<div>'+
     '<div style="height:20px;width:110px;display:inline-block;">Int. on L*a*b*:</div>'+
     cl.to_html( colors_interpolated )+'<br/>'+
     '<div style="height:20px;width:110px;display:inline-block;">HSL and back:</div>'+
     cl.to_html( after_hsl_and_back )+'<br/>'+
     '<div style="height:20px;width:110px;display:inline-block;">Int. on RGB:</div>'+
     cl.to_html( rgb_interpolated ))


['rgb(228,26,28)', 'rgb(213,60,56)', 'rgb(195,80,81)', 'rgb(175,94,106)', 'rgb(150,106,132)', 'rgb(117,117,158)', 'rgb(55,126,184)', 'rgb(70,134,167)', 'rgb(78,142,150)', 'rgb(82,150,132)', 'rgb(84,158,114)', 'rgb(82,167,95)', 'rgb(77,175,74)', 'rgb(101,161,92)', 'rgb(117,147,108)', 'rgb(130,132,122)', 'rgb(139,116,136)', 'rgb(147,98,150)', 'rgb(152,78,163)']
['rgb(230, 25, 29)', 'rgb(213, 62, 57)', 'rgb(195, 80, 82)', 'rgb(176, 94, 107)', 'rgb(149, 106, 131)', 'rgb(118, 118, 158)', 'rgb(55, 126, 185)', 'rgb(69, 133, 165)', 'rgb(78, 143, 151)', 'rgb(81, 148, 130)', 'rgb(83, 157, 112)', 'rgb(82, 167, 95)', 'rgb(77, 176, 74)', 'rgb(102, 162, 93)', 'rgb(117, 147, 108)', 'rgb(131, 133, 122)', 'rgb(139, 116, 136)', 'rgb(149, 99, 151)', 'rgb(151, 78, 162)']
['rgb(228,26,28)', 'rgb(199,42,54)', 'rgb(170,59,80)', 'rgb(141,76,106)', 'rgb(112,92,132)', 'rgb(83,109,158)', 'rgb(55,126,184)', 'rgb(58,134,165)', 'rgb(62,142,147)', 'rgb(66,150,129)', 'rgb(69,158,110)', 'rgb(73,166,92)', 'rgb(77,175,74)', 'rgb(89,158,88)', 'rgb(101,142,103)', 'rgb(114,126,118)', 'rgb(126,110,133)', 'rgb(139,94,148)', 'rgb(152,78,163)']
Out[27]:
Int. on L*a*b*:

HSL and back:

Int. on RGB: